--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit c6d396ba77b6acd07f0c25a73187d76077e84d60
Parents : 26086fc
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T22:47:52-05:00
feat(context-menu): refactor context menu implementation by creating reusable ContextMenuPanel, ContextMenuItem, ContextMenuDivider, and ContextMenuSectionLabel components; update styles and integrate components across various pages
Changes
11 files changed, 332 insertions(+), 231 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
index d6786e0a..a76864a0 100644
--- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue
+++ b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
@@ -140,42 +140,39 @@
</div>
<!-- Contact context menu -->
- <div
- v-if="contextMenu.visible"
- class="fixed z-[210] min-w-48 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl"
- :style="{ top: `${contextMenu.y}px`, left: `${contextMenu.x}px` }"
+ <ContextMenuPanel
+ :show="contextMenu.visible"
+ :x="contextMenu.x"
+ :y="contextMenu.y"
+ panel-class="z-[210]"
>
- <button type="button" class="context-item" @click="openConversation(contextMenu.contact)">
+ <ContextMenuItem @click="openConversation(contextMenu.contact)">
<MaterialDesignIcon icon-name="message-text-outline" class="size-4" />
{{ $t("contacts.send_message") }}
- </button>
- <button type="button" class="context-item" @click="callContact(contextMenu.contact)">
+ </ContextMenuItem>
+ <ContextMenuItem @click="callContact(contextMenu.contact)">
<MaterialDesignIcon icon-name="phone-outline" class="size-4" />
{{ $t("contacts.call_contact") }}
- </button>
- <div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
- <button type="button" class="context-item" @click="editContactName(contextMenu.contact)">
+ </ContextMenuItem>
+ <ContextMenuDivider />
+ <ContextMenuItem @click="editContactName(contextMenu.contact)">
<MaterialDesignIcon icon-name="pencil-outline" class="size-4" />
{{ $t("contacts.edit_contact") }}
- </button>
- <button type="button" class="context-item" @click="shareContact(contextMenu.contact)">
+ </ContextMenuItem>
+ <ContextMenuItem @click="shareContact(contextMenu.contact)">
<MaterialDesignIcon icon-name="share-variant" class="size-4" />
{{ $t("contacts.share_contact") }}
- </button>
- <button type="button" class="context-item" @click="copyContactUri(contextMenu.contact)">
+ </ContextMenuItem>
+ <ContextMenuItem @click="copyContactUri(contextMenu.contact)">
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
{{ $t("contacts.copy_contact_uri") }}
- </button>
- <div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
- <button
- type="button"
- class="context-item text-red-600 dark:text-red-400"
- @click="removeContact(contextMenu.contact)"
- >
+ </ContextMenuItem>
+ <ContextMenuDivider />
+ <ContextMenuItem item-class="text-red-600 dark:text-red-400" @click="removeContact(contextMenu.contact)">
<MaterialDesignIcon icon-name="delete-outline" class="size-4" />
{{ $t("contacts.remove_contact") }}
- </button>
- </div>
+ </ContextMenuItem>
+ </ContextMenuPanel>
<!-- Add contact dialog -->
<div
@@ -384,12 +381,18 @@ import ToastUtils from "../../js/ToastUtils";
import DialogUtils from "../../js/DialogUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
+import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
+import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
+import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
export default {
name: "ContactsPage",
components: {
MaterialDesignIcon,
LxmfUserIcon,
+ ContextMenuDivider,
+ ContextMenuItem,
+ ContextMenuPanel,
},
data() {
return {
@@ -891,8 +894,4 @@ export default {
.secondary-chip {
@apply inline-flex items-center gap-1 rounded-xl bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 text-xs font-semibold transition;
}
-
-.context-item {
- @apply w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800 flex items-center gap-2;
-}
</style>
diff --git a/meshchatx/src/frontend/components/contextmenu/ContextMenuDivider.vue b/meshchatx/src/frontend/components/contextmenu/ContextMenuDivider.vue
new file mode 100644
index 00000000..ecc0fb78
--- /dev/null
+++ b/meshchatx/src/frontend/components/contextmenu/ContextMenuDivider.vue
@@ -0,0 +1,9 @@
+<template>
+ <div class="context-menu-divider" role="separator" />
+</template>
+
+<script>
+export default {
+ name: "ContextMenuDivider",
+};
+</script>
diff --git a/meshchatx/src/frontend/components/contextmenu/ContextMenuItem.vue b/meshchatx/src/frontend/components/contextmenu/ContextMenuItem.vue
new file mode 100644
index 00000000..a7ff8995
--- /dev/null
+++ b/meshchatx/src/frontend/components/contextmenu/ContextMenuItem.vue
@@ -0,0 +1,22 @@
+<template>
+ <button type="button" class="context-item" :class="itemClass" :disabled="disabled" @click="$emit('click', $event)">
+ <slot />
+ </button>
+</template>
+
+<script>
+export default {
+ name: "ContextMenuItem",
+ props: {
+ itemClass: {
+ type: String,
+ default: "",
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ emits: ["click"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue b/meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue
new file mode 100644
index 00000000..118558b9
--- /dev/null
+++ b/meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue
@@ -0,0 +1,37 @@
+<template>
+ <div
+ v-if="show"
+ class="context-menu-panel"
+ :class="panelClass"
+ :style="{ top: y + 'px', left: x + 'px' }"
+ v-bind="$attrs"
+ >
+ <slot name="header" />
+ <slot />
+ </div>
+</template>
+
+<script>
+export default {
+ name: "ContextMenuPanel",
+ inheritAttrs: false,
+ props: {
+ show: {
+ type: Boolean,
+ required: true,
+ },
+ x: {
+ type: Number,
+ required: true,
+ },
+ y: {
+ type: Number,
+ required: true,
+ },
+ panelClass: {
+ type: String,
+ default: "",
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/contextmenu/ContextMenuSectionLabel.vue b/meshchatx/src/frontend/components/contextmenu/ContextMenuSectionLabel.vue
new file mode 100644
index 00000000..0a30f718
--- /dev/null
+++ b/meshchatx/src/frontend/components/contextmenu/ContextMenuSectionLabel.vue
@@ -0,0 +1,11 @@
+<template>
+ <div class="context-menu-section-label">
+ <slot />
+ </div>
+</template>
+
+<script>
+export default {
+ name: "ContextMenuSectionLabel",
+};
+</script>
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index eddaa295..f24e3743 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -300,56 +300,44 @@
</div>
<!-- context menu -->
- <div
- v-if="showContextMenu"
- class="fixed z-[120] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-2xl overflow-hidden text-sm text-gray-900 dark:text-zinc-100"
- :style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
+ <ContextMenuPanel
+ :show="showContextMenu"
+ :x="contextMenuPos.x"
+ :y="contextMenuPos.y"
+ panel-class="z-[120] overflow-hidden text-sm"
>
- <div class="px-3 py-2 font-bold border-b border-gray-100 dark:border-zinc-800">
- {{ contextMenuFeature ? "Feature actions" : "Map actions" }}
- </div>
- <div class="flex flex-col">
- <button
- v-if="contextMenuFeature"
- class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
- @click="contextSelectFeature"
- >
- <MaterialDesignIcon icon-name="cursor-default" class="size-4" />
- <span>Select / Move</span>
- </button>
- <button
- v-if="contextMenuFeature"
- class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
- @click="contextAddNote"
- >
- <MaterialDesignIcon icon-name="note-edit" class="size-4" />
- <span>Add / Edit Note</span>
- </button>
- <button
- v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
- class="flex items-center gap-2 px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-left text-red-600"
- @click="contextDeleteFeature"
- >
- <MaterialDesignIcon icon-name="delete" class="size-4" />
- <span>Delete</span>
- </button>
- <button
- class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
- @click="contextCopyCoords"
- >
- <MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
- <span>Copy coords</span>
- </button>
- <button
- v-if="!contextMenuFeature"
- class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
- @click="contextClearMap"
+ <template #header>
+ <div
+ class="px-3 py-2 font-semibold border-b border-gray-100 dark:border-zinc-800 text-gray-700 dark:text-zinc-200"
>
- <MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
- <span>Clear drawings</span>
- </button>
- </div>
- </div>
+ {{ contextMenuFeature ? "Feature actions" : "Map actions" }}
+ </div>
+ </template>
+ <ContextMenuItem v-if="contextMenuFeature" @click="contextSelectFeature">
+ <MaterialDesignIcon icon-name="cursor-default" class="size-4" />
+ Select / Move
+ </ContextMenuItem>
+ <ContextMenuItem v-if="contextMenuFeature" @click="contextAddNote">
+ <MaterialDesignIcon icon-name="note-edit" class="size-4" />
+ Add / Edit Note
+ </ContextMenuItem>
+ <ContextMenuItem
+ v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
+ item-class="text-red-600 dark:text-red-400"
+ @click="contextDeleteFeature"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-4" />
+ Delete
+ </ContextMenuItem>
+ <ContextMenuItem @click="contextCopyCoords">
+ <MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
+ Copy coords
+ </ContextMenuItem>
+ <ContextMenuItem v-if="!contextMenuFeature" @click="contextClearMap">
+ <MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
+ Clear drawings
+ </ContextMenuItem>
+ </ContextMenuPanel>
<!-- loading skeleton for map -->
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
@@ -1325,6 +1313,8 @@ import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
import { extend as extendExtent, createEmpty as createEmptyExtent, isEmpty as isExtentEmpty } from "ol/extent";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
+import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
import Toggle from "../forms/Toggle.vue";
@@ -1334,6 +1324,8 @@ import MiniChat from "./MiniChat.vue";
export default {
name: "MapPage",
components: {
+ ContextMenuItem,
+ ContextMenuPanel,
MaterialDesignIcon,
Toggle,
MiniChat,
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 2434dd14..627c8bff 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -518,58 +518,43 @@
</div>
<!-- Context Menu -->
- <div
- v-if="contextMenu.show"
+ <ContextMenuPanel
+ :show="contextMenu.show"
+ :x="contextMenu.x"
+ :y="contextMenu.y"
+ panel-class="z-[100]"
v-click-outside="{ handler: () => (contextMenu.show = false), capture: true }"
- class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="bulkMarkAsRead"
- >
+ <ContextMenuItem @click="bulkMarkAsRead">
<MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
- <span class="font-medium">Mark as Read</span>
- </button>
- <button
- v-if="contextMenu.targetHash"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="togglePinFromContextMenu"
- >
+ Mark as Read
+ </ContextMenuItem>
+ <ContextMenuItem v-if="contextMenu.targetHash" @click="togglePinFromContextMenu">
<MaterialDesignIcon
:icon-name="isContextTargetPinned ? 'pin-off' : 'pin'"
class="size-4 text-gray-400"
/>
- <span class="font-medium">{{
+ {{
isContextTargetPinned
? $t("messages.unpin_conversation")
: $t("messages.pin_conversation")
- }}</span>
- </button>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="contextMenuIngestPaperMessage"
- >
+ }}
+ </ContextMenuItem>
+ <ContextMenuItem @click="contextMenuIngestPaperMessage">
<MaterialDesignIcon icon-name="qrcode-scan" class="size-4 text-gray-400" />
- <span class="font-medium">{{ $t("messages.ingest_paper_message") }}</span>
- </button>
- <button
+ {{ $t("messages.ingest_paper_message") }}
+ </ContextMenuItem>
+ <ContextMenuItem
v-if="contextMenu.targetHash && isBlocked(contextMenu.targetHash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all active:scale-95"
+ item-class="text-emerald-600 dark:text-emerald-400"
@click="liftBanishmentFromConversationMenu"
>
<MaterialDesignIcon icon-name="check-circle" class="size-4" />
- <span class="font-medium">{{ $t("banishment.lift_banishment") }}</span>
- </button>
- <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
- <button
+ {{ $t("banishment.lift_banishment") }}
+ </ContextMenuItem>
+ <ContextMenuDivider />
+ <ContextMenuItem
v-if="GlobalState.config.telemetry_enabled"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="toggleTelemetryTrust(contextMenu.targetHash)"
>
<MaterialDesignIcon
@@ -580,56 +565,38 @@
"
:class="
contextMenu.targetContact?.is_telemetry_trusted
- ? 'text-blue-500'
- : 'text-gray-400'
+ ? 'size-4 text-blue-500'
+ : 'size-4 text-gray-400'
"
- class="size-4"
/>
- <span class="font-medium">{{
+ {{
contextMenu.targetContact?.is_telemetry_trusted
? "Revoke Telemetry Trust"
: "Trust for Telemetry"
- }}</span>
- </button>
- <div
- v-if="GlobalState.config.telemetry_enabled"
- class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"
- ></div>
- <div
- class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
- >
- Move to Folder
- </div>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
- @click="moveSelectedToFolder(null)"
- >
+ }}
+ </ContextMenuItem>
+ <ContextMenuDivider v-if="GlobalState.config.telemetry_enabled" />
+ <ContextMenuSectionLabel>Move to Folder</ContextMenuSectionLabel>
+ <ContextMenuItem @click="moveSelectedToFolder(null)">
<MaterialDesignIcon icon-name="inbox-arrow-down" class="size-4 opacity-70" />
- <span>Uncategorized</span>
- </button>
+ Uncategorized
+ </ContextMenuItem>
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
- <button
+ <ContextMenuItem
v-for="folder in folders"
:key="folder.id"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
@click="moveSelectedToFolder(folder.id)"
>
<MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
<span class="truncate">{{ folder.name }}</span>
- </button>
+ </ContextMenuItem>
</div>
- <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- @click="bulkDelete"
- >
+ <ContextMenuDivider />
+ <ContextMenuItem item-class="text-red-600 dark:text-red-400" @click="bulkDelete">
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
- <span class="font-bold">Delete</span>
- </button>
- </div>
+ Delete
+ </ContextMenuItem>
+ </ContextMenuPanel>
<!-- loading more spinner -->
<div v-if="isLoadingMore" class="p-4 text-center">
@@ -802,13 +769,24 @@ import Utils from "../../js/Utils";
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
+import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
+import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
+import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
+import ContextMenuSectionLabel from "../contextmenu/ContextMenuSectionLabel.vue";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import MarkdownRenderer from "../../js/MarkdownRenderer";
export default {
name: "MessagesSidebar",
- components: { MaterialDesignIcon, LxmfUserIcon },
+ components: {
+ MaterialDesignIcon,
+ LxmfUserIcon,
+ ContextMenuDivider,
+ ContextMenuItem,
+ ContextMenuPanel,
+ ContextMenuSectionLabel,
+ },
props: {
peers: {
type: Object,
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index 2d674510..0493c42f 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -277,101 +277,84 @@
<!-- Favourite Context Menu (Teleport to body to avoid overflow clipping) -->
<Teleport to="body">
- <div
- v-if="favouriteContextMenu.show"
+ <ContextMenuPanel
+ :show="favouriteContextMenu.show"
+ :x="favouriteContextMenu.x"
+ :y="favouriteContextMenu.y"
+ panel-class="z-[200] min-w-56"
v-click-outside="{
handler: () => {
if (!favouriteContextMenu.justOpened) closeContextMenus();
},
capture: true,
}"
- class="fixed z-[200] min-w-[220px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: favouriteContextMenu.y + 'px', left: favouriteContextMenu.x + 'px' }"
>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="renameFavouriteFromContext"
- >
+ <ContextMenuItem @click="renameFavouriteFromContext">
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
- <span class="font-medium">{{ $t("nomadnet.rename") }}</span>
- </button>
- <button
+ {{ $t("nomadnet.rename") }}
+ </ContextMenuItem>
+ <ContextMenuItem
v-if="!isBlocked(favouriteContextMenu.targetHash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ item-class="text-red-600 dark:text-red-400"
@click="banishFavouriteFromContext"
>
<MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
- <span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
- </button>
- <button
+ {{ $t("nomadnet.block_node") }}
+ </ContextMenuItem>
+ <ContextMenuItem
v-else
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
+ item-class="text-emerald-600 dark:text-emerald-400"
@click="unblockFavouriteFromContext"
>
- <MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
- <span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
- </button>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- @click="removeFavouriteFromContext"
- >
+ <MaterialDesignIcon icon-name="check-circle" class="size-4 text-emerald-500" />
+ {{ $t("nomadnet.lift_banishment") }}
+ </ContextMenuItem>
+ <ContextMenuItem item-class="text-red-600 dark:text-red-400" @click="removeFavouriteFromContext">
<MaterialDesignIcon icon-name="trash-can" class="size-4 text-red-400" />
- <span class="font-medium">{{ $t("nomadnet.remove") }}</span>
- </button>
- <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
- <div
- class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
- >
- Move to Section
- </div>
+ {{ $t("nomadnet.remove") }}
+ </ContextMenuItem>
+ <ContextMenuDivider />
+ <ContextMenuSectionLabel>Move to Section</ContextMenuSectionLabel>
<div class="max-h-56 overflow-y-auto custom-scrollbar">
- <button
+ <ContextMenuItem
v-for="section in sectionsWithFavourites"
:key="section.id + '-move'"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
@click="moveContextFavouriteToSection(section.id)"
>
<MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
<span class="truncate">{{ section.name }}</span>
- </button>
+ </ContextMenuItem>
</div>
- </div>
+ </ContextMenuPanel>
</Teleport>
<!-- Section Context Menu (Teleport to body) -->
<Teleport to="body">
- <div
- v-if="sectionContextMenu.show"
+ <ContextMenuPanel
+ :show="sectionContextMenu.show"
+ :x="sectionContextMenu.x"
+ :y="sectionContextMenu.y"
+ panel-class="z-[200]"
v-click-outside="{ handler: closeContextMenus, capture: true }"
- class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: sectionContextMenu.y + 'px', left: sectionContextMenu.x + 'px' }"
>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="renameSectionFromContext"
- >
+ <ContextMenuItem @click="renameSectionFromContext">
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
- <span class="font-medium">Rename Section</span>
- </button>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
- :disabled="sectionContextMenu.sectionId === defaultSectionId"
- :class="
- sectionContextMenu.sectionId === defaultSectionId ? 'opacity-50 cursor-not-allowed' : ''
+ Rename Section
+ </ContextMenuItem>
+ <ContextMenuItem
+ :item-class="
+ 'text-red-600 dark:text-red-400' +
+ (sectionContextMenu.sectionId === defaultSectionId
+ ? ' opacity-50 cursor-not-allowed'
+ : '')
"
+ :disabled="sectionContextMenu.sectionId === defaultSectionId"
@click="removeSectionFromContext"
>
<MaterialDesignIcon icon-name="delete" class="size-4 text-red-400" />
- <span class="font-medium">Delete Section</span>
- </button>
- </div>
+ Delete Section
+ </ContextMenuItem>
+ </ContextMenuPanel>
</Teleport>
</div>
@@ -478,45 +461,42 @@
<!-- Announce Context Menu (right-click, Teleport to body) -->
<Teleport to="body">
- <div
- v-if="announceContextMenu.show"
+ <ContextMenuPanel
+ :show="announceContextMenu.show"
+ :x="announceContextMenu.x"
+ :y="announceContextMenu.y"
+ panel-class="z-[200]"
v-click-outside="{
handler: () => {
if (!announceContextMenu.justOpened) closeContextMenus();
},
capture: true,
}"
- class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: announceContextMenu.y + 'px', left: announceContextMenu.x + 'px' }"
>
- <button
+ <ContextMenuItem
v-if="!isFavourite(announceContextMenu.node?.destination_hash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="addFavouriteFromContext"
>
<MaterialDesignIcon icon-name="star-outline" class="size-4 text-yellow-500" />
- <span class="font-medium">{{ $t("nomadnet.add_favourite") }}</span>
- </button>
- <button
+ {{ $t("nomadnet.add_favourite") }}
+ </ContextMenuItem>
+ <ContextMenuItem
v-if="!isBlocked(announceContextMenu.node?.identity_hash)"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ item-class="text-red-600 dark:text-red-400"
@click="blockAnnounceFromContext"
>
<MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
- <span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
- </button>
- <button
+ {{ $t("nomadnet.block_node") }}
+ </ContextMenuItem>
+ <ContextMenuItem
v-else
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
+ item-class="text-emerald-600 dark:text-emerald-400"
@click="unblockAnnounceFromContext"
>
- <MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
- <span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
- </button>
- </div>
+ <MaterialDesignIcon icon-name="check-circle" class="size-4 text-emerald-500" />
+ {{ $t("nomadnet.lift_banishment") }}
+ </ContextMenuItem>
+ </ContextMenuPanel>
</Teleport>
</div>
</template>
@@ -526,6 +506,10 @@
<script>
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
+import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
+import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
+import ContextMenuSectionLabel from "../contextmenu/ContextMenuSectionLabel.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
@@ -536,7 +520,16 @@ import ToastUtils from "../../js/ToastUtils";
export default {
name: "NomadNetworkSidebar",
- components: { DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon },
+ components: {
+ ContextMenuDivider,
+ ContextMenuItem,
+ ContextMenuPanel,
+ ContextMenuSectionLabel,
+ DropDownMenuItem,
+ IconButton,
+ DropDownMenu,
+ MaterialDesignIcon,
+ },
props: {
nodes: {
type: Object,
diff --git a/meshchatx/src/frontend/style.css b/meshchatx/src/frontend/style.css
index 8ca5fbe8..df7ad7ad 100644
--- a/meshchatx/src/frontend/style.css
+++ b/meshchatx/src/frontend/style.css
@@ -207,3 +207,19 @@ select.input-field option {
.animate-spin-reverse {
animation: spin-reverse 1s linear infinite;
}
+
+.context-menu-panel {
+ @apply fixed min-w-48 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl;
+}
+
+.context-item {
+ @apply w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800 flex items-center gap-2;
+}
+
+.context-menu-divider {
+ @apply border-t border-gray-100 dark:border-zinc-800 my-1;
+}
+
+.context-menu-section-label {
+ @apply px-3 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest;
+}
diff --git a/tests/frontend/NomadNetworkSidebar.test.js b/tests/frontend/NomadNetworkSidebar.test.js
index 4c9b2d45..29dc1b66 100644
--- a/tests/frontend/NomadNetworkSidebar.test.js
+++ b/tests/frontend/NomadNetworkSidebar.test.js
@@ -108,7 +108,7 @@ describe("NomadNetworkSidebar.vue", () => {
wrapper.vm.sectionContextMenu.show = false;
await wrapper.vm.$nextTick();
- const menuEls = document.body.querySelectorAll(".fixed");
+ const menuEls = document.body.querySelectorAll(".context-menu-panel");
const menuEl = Array.from(menuEls).find((el) => el.textContent.includes("nomadnet.rename"));
expect(menuEl).toBeTruthy();
expect(menuEl.textContent).toContain("nomadnet.block_node");
@@ -152,7 +152,7 @@ describe("NomadNetworkSidebar.vue", () => {
wrapper.vm.sectionContextMenu.show = false;
await wrapper.vm.$nextTick();
- const menuEls = document.body.querySelectorAll(".fixed");
+ const menuEls = document.body.querySelectorAll(".context-menu-panel");
const menuEl = Array.from(menuEls).find((el) => el.textContent.includes("nomadnet.rename"));
expect(menuEl).toBeTruthy();
const banishBtn = Array.from(menuEl.querySelectorAll("button")).find((b) =>
diff --git a/tests/frontend/contextMenuStyles.test.js b/tests/frontend/contextMenuStyles.test.js
new file mode 100644
index 00000000..1cf0c9d0
--- /dev/null
+++ b/tests/frontend/contextMenuStyles.test.js
@@ -0,0 +1,44 @@
+import { readFileSync } from "fs";
+import { join } from "path";
+import { describe, it, expect } from "vitest";
+
+const root = process.cwd();
+
+function readProjectFile(relativePath) {
+ return readFileSync(join(root, relativePath), "utf8");
+}
+
+describe("context menu styling", () => {
+ it("defines shared classes in style.css", () => {
+ const css = readProjectFile("meshchatx/src/frontend/style.css");
+ expect(css).toMatch(/\.context-menu-panel\s*\{/);
+ expect(css).toMatch(/\.context-item\s*\{/);
+ expect(css).toMatch(/\.context-menu-divider\s*\{/);
+ expect(css).toMatch(/\.context-menu-section-label\s*\{/);
+ expect(css).toContain("min-w-48");
+ expect(css).toContain("rounded-xl");
+ expect(css).toContain("shadow-xl");
+ });
+
+ it("ContextMenuPanel and ContextMenuItem components define the shared CSS classes", () => {
+ const panel = readProjectFile("meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue");
+ const item = readProjectFile("meshchatx/src/frontend/components/contextmenu/ContextMenuItem.vue");
+ expect(panel).toContain("context-menu-panel");
+ expect(item).toContain("context-item");
+ });
+
+ it("uses ContextMenuPanel and ContextMenuItem on all right-click context menus", () => {
+ const files = [
+ "meshchatx/src/frontend/components/contacts/ContactsPage.vue",
+ "meshchatx/src/frontend/components/messages/MessagesSidebar.vue",
+ "meshchatx/src/frontend/components/messages/ConversationViewer.vue",
+ "meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue",
+ "meshchatx/src/frontend/components/map/MapPage.vue",
+ ];
+ for (const f of files) {
+ const src = readProjectFile(f);
+ expect(src, f).toContain("ContextMenuPanel");
+ expect(src, f).toContain("ContextMenuItem");
+ }
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────